Capture sector "Other" free-text as records; curate (promote/keep/dismiss)#1939
Conversation
| # they can be curated (promoted/kept/dismissed). Reuses OtherOption.texts, the | ||
| # same extraction used to display them, and de-dupes on the person's normalized | ||
| # value so a repeat registration of the same text doesn't create a second row. | ||
| def capture_other_sector_responses(submission) |
There was a problem hiding this comment.
🤖 From Claude: Capture reuses OtherOption.texts (the same extraction the display used) and find_or_create_by on the person + normalized value, so a repeat registration of the same "Other" text doesn't create a second row or trip the unique index.
| sector = target_sector | ||
| return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector | ||
|
|
||
| responses = OtherResponse.sectors.promotable_now |
There was a problem hiding this comment.
🤖 From Claude: promotable_now excludes dismissed — so "dismissed wins": promoting a value never re-tags someone who deliberately hid it.
| <%= response.text %> | ||
| <span class="ml-1 text-xs text-gray-400">(other)</span> | ||
| <% if dismissable %> | ||
| <%= link_to "×", |
There was a problem hiding this comment.
🤖 From Claude: Dismiss is a link_to with turbo_method: :patch, not button_to — this chip lives inside the person edit <form>, and a nested <form> (what button_to emits) is invalid HTML.
f22fbe8 to
1b3fb0f
Compare
|
|
||
| # Bulk keep/dismiss every visible person who typed this value, from the review | ||
| # queue. Keep leaves it as a free-text chip; dismiss hides it from profiles. | ||
| def curate |
There was a problem hiding this comment.
🤖 From Claude: Bulk keep/dismiss acts on the whole grouped value (all VISIBLE_STATUSES rows for this normalized_text), mirroring how promote fans out across everyone who typed it. return_status round-trips the active filter so the queue stays put after the action.
|
what we have now on profile w form submission data is super brittle. this would allow us to capture all form 'other' answer data and decide if it gets promoted, wiped, or kept as auxiliary data. |
| # Bulk keep/dismiss every visible person who typed this value, from the review | ||
| # queue. Keep leaves it as a free-text chip; dismiss hides it from profiles. | ||
| def curate | ||
| authorize! to: :update? |
There was a problem hiding this comment.
update? is not defined in other_response_policy.rb so it will default to application_policy. While this will work as it hits the admin check, we are explicit in every other policy instead of falling back to application_policy
There was a problem hiding this comment.
🤖 From Claude: Fixed in 65d48db → 8933c0a. other_response_policy.rb now spells out index?/update?/curate?/promote? explicitly (all admin?), and curate uses plain authorize! so it resolves curate? instead of borrowing update?. Nothing falls back to application_policy anymore.
| @@ -0,0 +1,8 @@ | |||
| class OtherResponsePolicy < ApplicationPolicy | |||
| # Curating free-text "Other" responses (reviewing, promoting, keeping, | |||
| # dismissing) is an admin-only task. index?/update? fall back to manage?. | |||
There was a problem hiding this comment.
Doesn't make sense. yes is falls back to manage in application policy but that is admin only as well
def manage?
admin?
end
There was a problem hiding this comment.
🤖 From Claude: Agreed it was inconsistent — a lone promote? while everything else leaned on the inherited manage?. Rewrote the policy to define every rule the controller authorizes explicitly (index?/update?/curate?/promote?, each admin?), matching the other policies, and added non-admin rejection specs for curate and promote.
| # must not land in the person's review queue. Organization type is synced | ||
| # onto the org (PublicRegistration#sync_agency_type) and will get its own | ||
| # promotable path when OrganizationType becomes a model. | ||
| EXCLUDED_FIELD_IDENTIFIERS = %w[agency_type].freeze |
There was a problem hiding this comment.
🤖 From Claude: agency_type is answered on the reg form and its answer IS saved into the person's form_answers (that's how #1875 reads it), so a naive "capture all fields" would slurp the org's "Other: …" into the person's review queue. Excluded here; it gets its own org-scoped, promotable path when OrganizationType lands.
|
|
||
| # Group key: sector "Other"s share one bucket (kind), every other question is | ||
| # keyed by its field so distinct questions never merge. | ||
| def group_bucket(response) |
There was a problem hiding this comment.
🤖 From Claude: Sector "Other"s from different sector fields (additional_sectors vs the legacy service-area names) bucket together since they all promote into the one Sector catalog; every other question stays keyed by its own field so distinct questions never merge. curate/promote scope the same way (group_scope).
78f34f2 to
42cf63d
Compare
Free-text "Other" sector values were derived on the fly from form answers and shown as read-only chips with nowhere to hang a curation decision. Materialize them as records so a curator can promote a recurring value into a real Sector, keep it as a free-text chip, or dismiss it (hide from profile/edit). Data layer only: migration, model, factory, specs. `kind` leaves room for the identical workshop-setting responses to migrate here later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Capture free-text "Other" sector answers as OtherResponse records at registration time (PublicRegistration), de-duped per person. - Person#other_sector_responses now reads the visible (pending/kept) records; profile + edit render them via a records-based partial. The edit chip gains an × that dismisses (hides) a response. Submission view is untouched (audit). - Admin review page (/other_responses) groups the same value across people with a count; promote maps them all to an existing sector or mints a new published one and tags every non-dismissed person. Reachable from the Sectors index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The review page could only promote; add Keep all / Dismiss all bulk actions per grouped value, and a status filter (All / Pending / Kept) so kept responses stay visible in the queue but can be filtered out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Justin <16829344+jmilljr24@users.noreply.github.com>
Per review: define index?/update?/curate?/promote? explicitly (all admin?) rather than leaning on ApplicationPolicy's inherited manage? rule, matching how the other policies are written. Controller uses plain authorize! so each action resolves its own rule. Adds non-admin rejection specs for curate and promote. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the capture so any question's free-text "Other" is materialized as an OtherResponse, on all three submission paths (registration, scholarship, bulk payment) via a shared OtherResponses::CaptureFromSubmission service. - OtherResponse gains field_identifier (which question) and derives kind: "sector" (promotable into a Sector, shown on the profile) or "generic" (auxiliary data, admin-review only). Unique per person + field + value. - Org-owned agency_type "Other" is excluded — it belongs to the organization (synced by #1875) and will get its own promotable path when OrganizationType becomes a model. - Review page groups by question (sectors bucketed together, each other question on its own), shows Promote only for sector groups; keep/dismiss for all. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse the "capture every question" scope: only questions whose "Other" can become a real tag are stored (sectors today; org-type once OrganizationType is a model). Every other "Other" stays searchable in the form answers rather than duplicated here. field_identifier is kept so switching a new question on later is a one-line change. Also let admins curate from the person: the profile and edit sector "Other" chips deep-link into the review queue (anchored to the row, with a back link to the person), where they can promote/keep/dismiss. Edit keeps its one-click × too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OtherResponse.owner is now polymorphic: a sector "Other" is owned by the Person (promotable, shown on their profile), an organization-type "Other" is owned by the Organization. Org-type is captured in sync_agency_type (where the org is known), stored now but not promotable — its promote button is hidden until OrganizationType is a model. The review queue groups by kind and labels the org-type group; keep/dismiss work for it, promote stays sector-only. Non-promotable "generic" questions are still not captured (searchable in form answers). field_identifier is retained so any question can be switched on later. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The promote form wrapped its button onto a second line and pushed Keep all / Dismiss all to a third. Put all actions in one flex row: the promote controls stay together (constrained input widths, nowrap), with Keep all / Dismiss all inline after them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Other" is the free-text catch-all (captured as an OtherResponse), not a selectable sector — but the person sector picker was the one selectable-sector list built without excluding_other, so it could be added and saved as the literal "Other" sector tag. - People picker now uses excluding_other, like every other selectable list. - tag_sectors (Person/Org) drops the Other sector id defensively. - Profile + edit drop a stray legacy "Other" tag from the displayed sectors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulling "Other" out of real sector tags left the background report with no "Other" signal at all. Restore it in two roles: a single aggregate "Other" slice in the All-sectors chart (how many chose it) and a separate detail card of the actual typed values (what they wrote), each drilling into its registrants. The primary chart gets no slice — "Other" is additional-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promotion tagged only the person; registration tags the person and their organizations. Extract that shared logic into SectorTagging.apply (person gets primary + additional, orgs get everything as additional-only) and route both registration and promotion through it. Promotion always passes additional-only, so a promoted sector now lands on the person and every org they're affiliated with, matching how a registration would have tagged it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The source_form_answer already ties a response to its submission and event, so the org can be found via the person's registration for that event rather than stored on the response or guessed from all current affiliations. Promotion now tags exactly the org(s) the person registered with when they typed the value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Action cell crammed a dropdown, "or", a text field, and one "Promote" button together, so it wasn't clear there are two distinct paths. Split them into two labeled actions — "New" (create a sector from the typed value → Create sector) and "Existing" (map it into an existing sector → Merge, the dedupe path) — separated from the Keep/Dismiss triage. Confirm dialogs and header copy now name the outcome of each. No controller change; each path posts the param the controller already handles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42cf63d to
889cfae
Compare
Dismissing hid a response everywhere, so a value you might still want to promote later was gone. Now dismiss only hides the chip from the person's profile — the response stays in the review queue (new REVIEWABLE_STATUSES: pending/kept/ dismissed; only promoted leaves). Promote acts on the whole group including dismissed, "Keep all" can revive a dismissed value, and the queue gains a Dismissed filter plus a dismissed count on each row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promoted values dropped off the queue with no way to see what became a tag. Add a Promoted filter tab (any status is now filterable; the default "All" still shows only the reviewable set). A promoted group renders read-only — a check + link to the sector it became — with no promote/keep/dismiss actions, since it's already a real tag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promoting via the Merge path folds a value into a differently-named existing sector, so "Promoted to" read wrong. When the promoted-to sector's name differs from the typed value, label it "Merged to <sector>"; when it matches (the value became its own tag) keep "Promoted to". Both are status: promoted, so they stay on the Promoted filter either way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖 suggested review level: 5 Inspect 🔬 new polymorphic model + capture across 3 submission paths + org-type capture + admin curation reachable from each person's chips
Why
Free-text "Other" answers people type on forms were derived on the fly and shown as read-only chips — with nowhere to record a curation decision, and no way to hide one from the public profile. This captures the promotable-track "Other"s as records so admins can promote / keep / dismiss them, from the review queue or from a person's page.
What
OtherResponsemodel — polymorphicowner, because the answer's subject differs by question:sector→ owned by the Person; promotable into aSector, shown on the profile/edit chip.organization_type→ owned by the Organization; stored now, not promotable untilOrganizationTypeis a model (its promote button is hidden).genericquestions are not captured — that data stays searchable in the form answers.field_identifieris retained so any question can be switched on later.pending→promoted/kept/dismissed.OtherResponses::CaptureFromSubmission(registration, scholarship, bulk payment); org-type viaPublicRegistration#sync_agency_type(where the org is known). De-duped per owner + question. No backfill./other_responses): groups by kind/question with a count, filterable All / Pending / Kept. Promote (sectors only) maps a group to an existing sector or mints a new published one; Keep all / Dismiss all for any group (incl. org-type).Decisions baked in
agency_typestays on the org too (Sync registration "Other" organization type onto the linked org #1875).index?/update?/curate?/promote?, alladmin?).Tests
Model (kind derivation incl. org-type, polymorphic owner, promotability, anchors, uniqueness), capture service (person/sector), org-type capture in registration, profile/edit chip + dismiss + deep-link, review page (sector/generic/org-type groups, filter, curate by kind, promote incl. new-sector minting, eyebrow return).
Rebased on main
Resolved conflicts with #1872 (age-range editor); coexists with #1875 (org
agency_type_other).Reporting (event background page)
Pulling "Other" out of real sector tags left the background report with no "Other" signal. Restored in two roles, mirroring the profile (sector chips + separate
(other)chips):